一、概述
Socket 是 Java 网络编程的基础,了解还是有好处的,这篇文章主要讲解 Socket 的基础编程。Socket 用在哪呢,主要用在进程间,网络间通信。
1.1、网络编程中两个主要的问题
一个是如何准确的定位网络上一台或多台主机,另一个就是找到主机后如何可靠高效的进行数据传输。
在 TCP/I P协议中 IP 层主要负责网络主机的定位,数据传输的路由,由 IP 地址可以唯一地确定 Internet 上的一台主机。
而 TCP 层则提供面向应用的可靠(TCP)的或非可靠(UDP)的数据传输机制,这是网络编程的主要对象,一般不需要关心 IP 层是如何处理数据的。
目前较为流行的网络编程模型是客户机/服务器(C/S)结构。 即通信双方一方作为服务器等待客户提出请求并予以响应。客户则在需要服务时向服务器提出申请。服务器一般作为守护进程始终运行,监听网络端口,一旦有客户请求,就会启动一个服务进程来响应该客户,同时自己继续监听服务端口,使后来的客户也能及时得到服务。
1.2、两类传输协议:TCP 和 UDP
TCP 是 Tranfer Control Protocol 的简称,是一种面向连接的保证可靠传输的协议。通过 TCP 协议传输,得到的是一个顺序的无差错的数据流。发送方和接收方的成对的两个 socket 之间必须建立连接,以便在 TCP 协议的基础上进行通信,当一个 socket(通常都是server socket)等待建立连接时,另一个 socket 可以要求进行连接,一旦这两个 socket 连接起来,它们就可以进行双向数据传输,双方都可以进行发送或接收操作。
UDP 是 User Datagram Protocol 的简称,是一种无连接的协议,每个数据报都是一个独立的信息,包括完整的源地址或目的地址,它在网络上以任何可能的路径传往目的地,因此能否到达目的地,到达目的地的时间以及内容的正确性都是不能被保证的。
TCP 和 UDP 的区别:
UDP:
1)每个数据报中都给出了完整的地址信息,因此无需要建立发送方和接收方的连接。
2)UDP传输数据时是有大小限制的,每个被传输的数据报必须限定在 64KB 之内。
3)UDP 是一个不可靠的协议,发送方所发送的数据报并不一定以相同的次序到达接收方。
TCP:
1)面向连接的协议,在 socket 之间进行数据传输之前必然要建立连接,所以在 TCP 中需要连接时间。
2)TCP 传输数据大小限制,一旦连接建立起来,双方的 socket 就可以按统一的格式传输大的数据。
3)TCP是一个可靠的协议,它确保接收方完全正确地获取发送方所发送的全部数据。
应用:
1,TCP 在网络通信上有极强的生命力,例如远程连接(Telnet)和文件传输(FTP)都需要不定长度的数据被可靠地传输。但是可靠的传输是要付出代价的,对数据内容正确性的检验必然占用计算机的处理时间和网络的带宽,因此 TCP 传输的效率不如 UDP 高。
2,UDP 操作简单,而且仅需要较少的监护,因此通常用于局域网高可靠性的分散系统中 client/server 应用程序。例如视频会议系统,并不要求音频视频数据绝对的正确,只要保证连贯性就可以了,这种情况下显然使用UDP会更合理一些。
二、Java Socket
2.1、什么是 Socket
网络上的两个程序通过一个双向的通讯连接实现数据的交换,这个双向链路的一端称为一个 Socket。Socket 通常用来实现客户方和服务方的连接。Socket 是 TCP/IP 协议的一个十分流行的编程界面,一个 Socket 由一个 IP 地址和一个端口号唯一确定。
但是,Socket 所支持的协议种类也不光 TCP/IP 一种,因此两者之间是没有必然联系的。在 Java 环境下,Socket 编程主要是指基于 TCP/IP 协议的网络编程。
2.2、Socket 通讯的过程
Server 端 Listen(监听)某个端口是否有连接请求,Client 端向 Server 端发出 Connect(连接)请求,Server 端向 Client 端发回 Accept(接受)消息。一个连接就建立起来了。Server 端和 Client 端都可以通过 Send,Write 等方法与对方通信。
对于一个功能齐全的 Socket,都要包含以下基本结构,其工作过程包含以下四个基本的步骤:
1)创建 Socket;
2)打开连接到 Socket 的输入/出流;
3)按照一定的协议对 Socket 进行读/写操作;
4)关闭 Socket.(在实际应用中,并未使用到显示的 close,虽然很多文章都推荐如此,不过在我的程序中,可能因为程序本身比较简单,要求不高,所以并未造成什么影响。)
2.3、创建 Socket
Java 在包 java.net 中提供了两个类:Socket 和 ServerSocket,分别用来表示双向连接的客户端和服务端。这是两个封装得非常好的类,使用很方便。其构造方法如下:
其中 address、host 和 port 分别是双向连接中另一方的 IP 地址、主机名和端口号,stream 指明 socket 是流 socket 还是数据报 socket,localPort 表示本地主机的端口号,localAddr 和 bindAddr 是本地机器的地址(ServerSocket 的主机地址),impl 是 socket 的父类,既可以用来创建 serverSocket 又可以用来创建 Socket。count 则表示服务端所能支持的最大连接数。
注意,在选择端口时,必须小心。每一个端口提供一种特定的 服务,只有给出正确的端口,才能获得相应的服务。0~1023 的端口号为系统所保留,例如 http 服务的端口号为 80,telnet 服务的端口号为 21,ftp 服务的端口号为 23,所以我们在选择端口号时,最好选择一个大于 1023 的数以防止发生冲突。
在创建 socket 时如果发生错误,将产生 IOException,在程序中必须对之作出处理。所以在创建 Socket 或 ServerSocket 是必须捕获或抛出例外。
2.4 示例
服务器端:
客户端:
客户端通过 IP 和端口,连接到指定的 server,然后通过 Socket 获得输出流,并向其输出内容,服务器会获得消息。最终服务端控制台打印如下:
通过这个例子应该掌握并了解:
- Socket 服务端和客户端的基本编程
- 传输编码统一指定,防止乱码
这个例子做为学习的基本例子,实际开发中会有各种变形,比如客户端在发送完消息后,需要服务端进行处理并返回。
三、消息通信优化
3.1 双向通信,发送消息并接受消息
|
|
与之前 server 的不同在于,当读取完客户端的消息后,打开输出流,将指定消息发送回客户端,客户端程序为:
客户端也有相应的变化,在发送完消息时,调用关闭输出流方法,然后打开输出流,等候服务端的消息。
3.2 关闭通信
正常来说,客户端打开一个输出流,如果不做约定,也不关闭它,那么服务端永远不知道客户端是否发送完消息,那么服务端会一直等待下去,直到读取超时。所以怎么告知服务端已经发送完消息就显得特别重要。
3.2.1 关闭 Socket
当 Socket 关闭的时候,服务端就会收到响应的关闭信号,那么服务端也就知道流已经关闭了,这个时候读取操作完成,就可以继续后续工作。但是这种方式有一些缺点
- 客户端 Socket 关闭后,将不能接受服务端发送的消息,也不能再次发送消息。
- 如果客户端想再次发送消息,需要重现创建 Socket 连接。
3.2.2 shutdownOutput
通过 Socket 关闭输出流的方式是:
而不是(outputStream为发送消息到服务端打开的输出流):
如果关闭了输出流,那么相应的 Socket 也将关闭,和直接关闭 Socke t一个性质。
调用 Socket 的 shutdownOutput() 方法,底层会告知服务端我这边已经写完了,那么服务端收到消息后,就能知道已经读取完消息,如果服务端有要返回给客户的消息那么就可以通过服务端的输出流发送给客户端,如果没有,直接关闭 Socket。
这种方式通过关闭客户端的输出流,告知服务端已经写完了,虽然可以读到服务端发送的消息,但是还是有一点缺点:不能再次发送消息给服务端,如果再次发送,需要重新建立Socket连接。
这个缺点,在访问频率比较高的情况下将是一个需要优化的地方。
3.2.3 通过约定符号
这种方式的用法,就是双方约定一个字符或者一个短语,来当做消息发送完成的标识,通常这么做就需要改造读取方法。
假如约定单独的一行为 end,代表发送完成,例如下面的消息,end则代表消息发送完成:
那么服务端响应的读取操作需要进行如下改造:
可以看见,服务端不仅判断是否读到了流的末尾,还判断了是否读到了约定的末尾。这么做的优缺点如下:
- 优点:不需要关闭流,当发送完一条命令(消息)后可以再次发送新的命令(消息)。
- 缺点:需要额外的约定结束标志,太简单的容易出现在要发送的消息中,误被结束,太复杂的不好处理,还占带宽。
经过了这么多的优化还是有缺点,难道就没有完美的解决方案吗,答案是有的,看接下来的内容。
3.2.4 通过指定长度
如果你了解一点 class 文件的结构,那么你就会佩服这么设计方式,也就是说我们可以在此找灵感,就是我们可以先指定后续命令的长度,然后读取指定长度的内容做为客户端发送的消息。
现在首要的问题就是用几个字节指定长度呢,我们可以算一算:
1个字节:最大256,表示256B
2个字节:最大65536,表示64K
3个字节:最大16777216,表示16M
4个字节:最大4294967296,表示4G
依次类推…
这个时候是不是很纠结,最大的当然是最保险的,但是真的有必要选择最大的吗,其实如果你稍微了解一点UTF-8的编码方式,那么你就应该能想到为什么一定要固定表示长度字节的长度呢,我们可以使用变长方式来表示长度的表示,比如:
第一个字节首位为0:即0XXXXXXX,表示长度就一个字节,最大128,表示128B
第一个字节首位为110,那么附带后面一个字节表示长度:即110XXXXX 10XXXXXX,最大2048,表示2K
第一个字节首位为1110,那么附带后面二个字节表示长度:即110XXXXX 10XXXXXX 10XXXXXX,最大131072,表示128K
依次类推
上面提到的这种用法适合高富帅的程序员使用,一般呢,如果用作命名发送,两个字节就够了,如果还不放心4个字节基本就能满足你的所有要求,下面的例子我们将采用2个字节表示长度,目的只是给你一种思路,让你知道有这种方式来获取消息的结尾:
服务端程序:
此处的读取步骤为,先读取两个字节的长度,然后读取消息,客户端为:
客户端要多做的是,在发送消息之前先把消息的长度发送过去。
当然如果是需要服务器返回结果,那么也依然使用这种方式,服务端也是先发送结果的长度,然后客户端进行读取。当然现在流行的就是,长度+类型+数据模式的传输方式。
四、服务端优化
4.1 服务端并发处理能力
在上面的例子中,服务端仅仅只是接受了一个 Socket 请求,并处理了它,然后就结束了,但是在实际开发中,一个 Socket 服务往往需要服务大量的 Socket 请求,那么就不能再服务完一个 Socket 的时候就关闭了,这时候可以采用循环接受请求并处理的逻辑:
这种一般也是新手写法,但是能够循环处理多个 Socket 请求,不过当一个请求的处理比较耗时的时候,后面的请求将被阻塞,所以一般都是用多线程的方式来处理 Socket,即每有一个 Socket 请求的时候,就创建一个线程来处理它。
不过在实际生产中,创建的线程会交给线程池来处理,为了:
- 线程复用,创建线程耗时,回收线程慢。
- 防止短时间内高并发,指定线程池大小,超过数量将等待,方式短时间创建大量线程导致资源耗尽,服务挂掉。
|
|
使用线程池的方式,算是一种成熟的方式。可以应用在生产中。
4.2 服务端其他属性
ServerSocket 有以下 3 个属性:
- SO_TIMEOUT:表示等待客户连接的超时时间。一般不设置,会持续等待。
- SO_REUSEADDR:表示是否允许重用服务器所绑定的地址。一般不设置,经我的测试没必要,下面会进行详解。
- SO_RCVBUF:表示接收数据的缓冲区的大小。一般不设置,用系统默认就可以了。
4.3 性能再次提升
当现在的性能还不能满足需求的时候,就需要考虑使用 NIO,这不是本篇的内容。
五、Socket 的其它知识
其实如果经常看有关网络编程的源码的话,就会发现Socket还是有很多设置的,可以学着用,但是还是要有一些基本的了解比较好。下面就对Socket的Java API中涉及到的进行简单讲解。首先呢Socket有哪些可以设置的选项,其实在SocketOptions接口中已经都列出来了:
- int TCP_NODELAY = 0x0001:对此连接禁用 Nagle 算法。
- int SO_BINDADDR = 0x000F:此选项为 TCP 或 UDP 套接字在 IP 地址头中设置服务类型或流量类字段。
- int SO_REUSEADDR = 0x04:设置套接字的 SO_REUSEADDR。
- int SO_BROADCAST = 0x0020:此选项启用和禁用发送广播消息的处理能力。
- int IP_MULTICAST_IF = 0x10:设置用于发送多播包的传出接口。
- int IP_MULTICAST_IF2 = 0x1f:设置用于发送多播包的传出接口。
- int IP_MULTICAST_LOOP = 0x12:此选项启用或禁用多播数据报的本地回送。
- int IP_TOS = 0x3:此选项为 TCP 或 UDP 套接字在 IP 地址头中设置服务类型或流量类字段。
- int SO_LINGER = 0x0080:指定关闭时逗留的超时值。
- int SO_TIMEOUT = 0x1006:设置阻塞 Socket 操作的超时值: ServerSocket.accept(); SocketInputStream.read(); DatagramSocket.receive(); 选项必须在进入阻塞操作前设置才能生效。
- int SO_SNDBUF = 0x1001:设置传出网络 I/O 的平台所使用的基础缓冲区大小的提示。
- int SO_RCVBUF = 0x1002:设置传入网络 I/O 的平台所使用基础缓冲区的大小的提示。
- int SO_KEEPALIVE = 0x0008:为 TCP 套接字设置 keepalive 选项时
- int SO_OOBINLINE = 0x1003:置 OOBINLINE 选项时,在套接字上接收的所有 TCP 紧急数据都将通过套接字输入流接收。
5.1 客户端绑定端口
服务端绑定端口是可以理解的,因为要监听指定的端口,但是客户端可以绑定端口吗?如果非要指定一个端口,就不能用Socket的构造方法,要一步一步来:
这样做就可以了,但是当这个程序执行完成以后,再次执行就会报,端口占用异常:
明明上一个Socket已经关闭了,为什么再次使用还会说已经被占用了呢?如果你是用netstat 命令来查看端口的使用情况:
就会发现端口的使用状态为TIME_WAIT。简单来说,当连接主动关闭后,端口状态变为TIME_WAIT,其他程序依然不能使用这个端口,防止服务端因为超时重新发送的确认连接断开对新连接的程序造成影响。
TIME_WAIT的时间一般有底层决定,一般是2分钟,还有1分钟和30秒的。
5.2 读超时 SO_TIMEOUT
读超时这个属性还是比较重要的,当Socket优化到最后的时候,往往一个Socket连接会一直用下去,那么当一端因为异常导致连接没有关闭,另一方是不应该持续等下去的,所以应该设置一个读取的超时时间,当超过指定的时间后,还没有读到数据,就假定这个连接无用,然后抛异常,捕获异常后关闭连接就可以了,调用方法为:
timeout - 指定的以毫秒为单位的超时值。设置0为持续等待下去。建议根据网络环境和实际生产环境选择。
这个选项设置的值将对以下操作有影响:
ServerSocket.accept()
SocketInputStream.read()
DatagramSocket.receive()
5.3 设置连接超时
这个连接超时和上面说的读超时不一样,读超时是在建立连接以后,读数据时使用的,而连接超时是在进行连接的时候,等待的时间。
5.4 判断Socket是否可用
当需要判断一个Socket是否可用的时候,不能简简单单判断是否为null,是否关闭,下面给出一个比较全面的判断Socket是否可用的表达式,这是根据Socket自身的一些状态进行判断的,它的状态有:
- bound:是否绑定
- closed:是否关闭
- connected:是否连接
- shutIn:是否关闭输入流
- shutOut:是否关闭输出流
|
|
建议如此使用,但这只是第一步,保证Socket自身的状态是可用的,但是当连接正常创建后,上面的属性如果不调用本方相应的方法是不会改变的,也就是说如果网络断开、服务器主动断开,Java底层是不会检测到连接断开并改变Socket的状态,所以,真实的检测连接状态还是得通过额外的手段,有两种方式。
5.4.1 自定义心跳包
双方需要约定,什么样的消息属于心跳包,什么样的消息属于正常消息,假设你看了上面的章节现在说就容易理解了,我们定义前两个字节为消息的长度,那么我们就可以定义第 3 个字节为消息的属性,可以指定一位为消息的类型,1 为心跳,0 为正常消息。那么要做的有如下:
- 客户端发送心跳包
- 服务端获取消息判断是否是心跳包,若是丢弃
- 当客户端发送心跳包失败时,就可以断定连接不可用
5.4.2 通过发送紧急数据
Socket 自带一种模式,那就是发送紧急数据,这有一个前提,那就是服务端的 OOBINLINE 不能设置为true,它的默认值是 false。
OOBINLINE 的 true 和 false 影响了什么:
- 对客户端没有影响
- 对服务端,如果设置为true,那么服务端将会捕获紧急数据,这会对接收数据造成混淆,需要额外判断
发送紧急数据通过调用Socket的方法:
发送数据任意即可,因为 OOBINLINE 为 false 的时候,服务端会丢弃掉紧急数据。当发送紧急数据报错以后,我们就会知道连接不通了。
5.4.3 真的需要判断连接断开吗
通过上面的两种方式已经可以判断出连接是否可用,然后我们就可以进行后续操作,可是请大家认真考虑下面的问题:
- 发送心跳成功时确认连接可用,当再次发送消息时能保证连接还可用吗?即便中间的间隔很短
- 如果连接不可用了,你会怎么做?重新建立连接再次发送数据?还是说单单只是记录日志?
- 如果你打算重新建立连接,那么发送心跳包的意义何在?为何不在发送异常时再新建连接?
如果你认真考虑了上面的问题,那么你就会觉得发送心跳包完全是没有必要的操作,通过发送心跳包来判断连接是否可用是通过捕获异常来判断的。那么我们完全可以在发送消息报出IO异常的时候,在异常中重新发送一次即可,这两种方式的编码有什么不同呢,下面写一写伪代码。
提前检测连接是否可用:
直接发送数据,出异常后重新连接再次发送:
通过比较可以发现两种方式的特点,现在简单介绍下:
- 两种方式均可实现连接断开重新连接并发送
- 提前检测,再每次发送消息的时候都要检测,影响效率,占用带宽
参考文章:
https://www.jianshu.com/p/cde27461c226
https://www.cnblogs.com/diaobiyong/p/9929319.html
https://www.cnblogs.com/futao123/p/5068632.html